#!/usr/bin/env python


__license__   = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

from collections import OrderedDict
from functools import partial

from qt.core import (
    QAbstractItemDelegate,
    QAbstractItemModel,
    QAbstractItemView,
    QApplication,
    QEvent,
    QFrame,
    QGridLayout,
    QHBoxLayout,
    QIcon,
    QItemSelectionModel,
    QKeyCombination,
    QKeyEvent,
    QKeySequence,
    QLabel,
    QMenu,
    QModelIndex,
    QObject,
    QPushButton,
    QRadioButton,
    QRectF,
    QSize,
    QStyle,
    QStyledItemDelegate,
    Qt,
    QTextDocument,
    QToolButton,
    QTreeView,
    QVBoxLayout,
    QWidget,
    pyqtSignal,
    sip,
)

from calibre import prepare_string_for_xml, prints
from calibre.constants import DEBUG
from calibre.gui2 import error_dialog, info_dialog
from calibre.gui2.search_box import SearchBox2
from calibre.utils.config import JSONConfig
from calibre.utils.icu import lower, sort_key
from calibre.utils.localization import pgettext
from calibre.utils.search_query_parser import ParseException, SearchQueryParser

ROOT = QModelIndex()


class NameConflict(ValueError):
    pass


def keysequence_from_event(ev):  # {{{
    k, mods = ev.keyCombination().key(), ev.modifiers()
    if k in (
            0, Qt.Key.Key_unknown, Qt.Key.Key_Shift, Qt.Key.Key_Control, Qt.Key.Key_Alt,
            Qt.Key.Key_Meta, Qt.Key.Key_AltGr, Qt.Key.Key_CapsLock, Qt.Key.Key_NumLock,
            Qt.Key.Key_ScrollLock):
        return
    letter = QKeySequence(k).toString(QKeySequence.SequenceFormat.PortableText)
    if mods & Qt.KeyboardModifier.ShiftModifier and letter.lower() == letter.upper():
        # Something like Shift+* or Shift+> we have to remove the shift,
        # since it is included in keycode.
        mods = mods & ~Qt.KeyboardModifier.ShiftModifier
    return QKeySequence(QKeyCombination(mods, k))
# }}}


def finalize(shortcuts, custom_keys_map={}):  # {{{
    '''
    Resolve conflicts and assign keys to every action in shortcuts, which must
    be a OrderedDict. User specified mappings of unique names to keys (as a
    list of strings) should be passed in custom_keys_map. Return a mapping
    of unique names to resolved keys. Also sets the set_to_default member
    correctly for each shortcut.
    '''
    seen, keys_map = {}, {}
    for unique_name, shortcut in shortcuts.items():
        custom_keys = custom_keys_map.get(unique_name, None)
        if custom_keys is None:
            candidates = shortcut['default_keys']
            shortcut['set_to_default'] = True
        else:
            candidates = custom_keys
            shortcut['set_to_default'] = False
        keys = []
        for x in candidates:
            ks = QKeySequence(x, QKeySequence.SequenceFormat.PortableText)
            x = str(ks.toString(QKeySequence.SequenceFormat.PortableText))
            if x in seen:
                if DEBUG:
                    prints('Key {!r} for shortcut {} is already used by'
                            ' {}, ignoring'.format(x, shortcut['name'], seen[x]['name']))
                keys_map[unique_name] = ()
                continue
            seen[x] = shortcut
            keys.append(ks)
        keys = tuple(keys)

        keys_map[unique_name] = keys
        ac = shortcut['action']
        if ac is None or sip.isdeleted(ac):
            if ac is not None and DEBUG:
                prints(f'Shortcut {unique_name!r} has a deleted action')
            continue
        ac.setShortcuts(list(keys))

    return keys_map
# }}}


class Manager(QObject):  # {{{

    def __init__(self, parent=None, config_name='shortcuts/main'):
        QObject.__init__(self, parent)

        self.config = JSONConfig(config_name)
        self.shortcuts = OrderedDict()
        self.keys_map = {}
        self.groups = {}

    def register_shortcut(self, unique_name, name, default_keys=(),
            description=None, action=None, group=None, persist_shortcut=False):
        '''
        Register a shortcut with calibre. calibre will manage the shortcut,
        automatically resolving conflicts and allowing the user to customize
        it.

        :param unique_name: A string that uniquely identifies this shortcut
        :param name: A user visible name describing the action performed by
        this shortcut
        :param default_keys: A tuple of keys that trigger this shortcut. Each
        key must be a string. For example: ('Ctrl+A', 'Alt+B', 'C',
        'Shift+Meta+D'). These keys will be assigned to the
        shortcut unless there is a conflict.
        :param action: A QAction object. The shortcut will cause this QAction
        to be triggered. Connect to its triggered signal in your code to
        respond to the shortcut.
        :param group: A string describing what "group" this shortcut belongs
        to. This is used to organize the list of shortcuts when the user is
        customizing them.
        :persist_shortcut: Shortcuts for actions that don't always
        appear, or are library dependent, may disappear when other
        keyboard shortcuts are edited unless ```persist_shortcut``` is
        set True.
        '''
        if unique_name in self.shortcuts:
            name = self.shortcuts[unique_name]['name']
            raise NameConflict(f'Shortcut for {unique_name!r} already registered by {name}')
        shortcut = {'name':name, 'desc':description, 'action': action,
                'default_keys':tuple(default_keys),
                'persist_shortcut':persist_shortcut}
        self.shortcuts[unique_name] = shortcut
        group = group if group else pgettext('keyboard shortcuts', _('Miscellaneous'))
        self.groups[group] = self.groups.get(group, []) + [unique_name]

    def unregister_shortcut(self, unique_name):
        '''
        Remove a registered shortcut. You need to call finalize() after you are
        done unregistering.
        '''
        self.shortcuts.pop(unique_name, None)
        for group in self.groups.values():
            try:
                group.remove(unique_name)
            except ValueError:
                pass

    def finalize(self):
        custom_keys_map = {un:tuple(keys) for un, keys in self.config.get('map', {}).items()}
        self.keys_map = finalize(self.shortcuts, custom_keys_map=custom_keys_map)

    def replace_action(self, unique_name, new_action):
        '''
        Replace the action associated with a shortcut.
        Once you're done calling replace_action() for all shortcuts you want
        replaced, call finalize() to have the shortcuts assigned to the replaced
        actions.
        '''
        sc = self.shortcuts[unique_name]
        sc['action'] = new_action

# }}}


# Model {{{

class Node:

    def __init__(self, group_map, shortcut_map, name=None, shortcut=None):
        self.data = name if name is not None else shortcut
        self.is_shortcut = shortcut is not None
        self.children = []
        if name is not None:
            self.children = [Node(None, None, shortcut=shortcut_map[uname])
                    for uname in group_map[name]]

    def __len__(self):
        return len(self.children)

    def __getitem__(self, row):
        return self.children[row]

    def __iter__(self):
        yield from self.children


class ConfigModel(SearchQueryParser, QAbstractItemModel):

    def __init__(self, keyboard, parent=None):
        QAbstractItemModel.__init__(self, parent)
        SearchQueryParser.__init__(self, ['all'])

        self.keyboard = keyboard
        groups = sorted(keyboard.groups, key=sort_key)
        shortcut_map = {k:v.copy() for k, v in self.keyboard.shortcuts.items()}
        for un, s in shortcut_map.items():
            s['keys'] = tuple(self.keyboard.keys_map.get(un, ()))
            s['unique_name'] = un
            s['group'] = [g for g, names in self.keyboard.groups.items() if un in
                    names][0]

        group_map = {group:sorted(names, key=lambda x:
                sort_key(shortcut_map[x]['name'])) for group, names in self.keyboard.groups.items()}

        self.data = [Node(group_map, shortcut_map, group) for group in groups]

    @property
    def all_shortcuts(self):
        for group in self.data:
            yield from group

    def rowCount(self, parent=ROOT):
        ip = parent.internalPointer()
        if ip is None:
            return len(self.data)
        return len(ip)

    def columnCount(self, parent=ROOT):
        return 1

    def index(self, row, column, parent=ROOT):
        ip = parent.internalPointer()
        if ip is None:
            ip = self.data
        try:
            return self.createIndex(row, column, ip[row])
        except Exception:
            pass
        return ROOT

    def parent(self, index):
        ip = index.internalPointer()
        if ip is None or not ip.is_shortcut:
            return ROOT
        group = ip.data['group']
        for i, g in enumerate(self.data):
            if g.data == group:
                return self.index(i, 0)
        return ROOT

    def data(self, index, role=Qt.ItemDataRole.DisplayRole):
        ip = index.internalPointer()
        if ip is not None and role == Qt.ItemDataRole.UserRole:
            return ip
        return None

    def flags(self, index):
        ans = QAbstractItemModel.flags(self, index)
        ip = index.internalPointer()
        if getattr(ip, 'is_shortcut', False):
            ans |= Qt.ItemFlag.ItemIsEditable
        return ans

    def restore_defaults(self):
        shortcut_map = {}
        for node in self.all_shortcuts:
            sc = node.data
            shortcut_map[sc['unique_name']] = sc
        shortcuts = OrderedDict([(un, shortcut_map[un]) for un in
            self.keyboard.shortcuts])
        keys_map = finalize(shortcuts)
        for node in self.all_shortcuts:
            s = node.data
            s['keys'] = tuple(keys_map[s['unique_name']])
        for r in range(self.rowCount()):
            group = self.index(r, 0)
            num = self.rowCount(group)
            if num > 0:
                self.dataChanged.emit(self.index(0, 0, group),
                        self.index(num-1, 0, group))

    def commit(self):
        kmap = {}
        # persist flags not in map for back compat
        # not *just* persist flag for forward compat
        options_map = {}
        options_map.update(self.keyboard.config.get('options_map', {}))
        # keep mapped keys that are marked persistent.
        for un, keys in self.keyboard.config.get('map', {}).items():
            if options_map.get(un, {}).get('persist_shortcut',False):
                kmap[un] = keys
        for node in self.all_shortcuts:
            sc = node.data
            un = sc['unique_name']
            if sc['set_to_default']:
                kmap.pop(un, None)
                options_map.pop(un, None)
            else:
                if sc['persist_shortcut']:
                    options_map[un] = options_map.get(un, {})
                    options_map[un]['persist_shortcut'] = sc['persist_shortcut']
                keys = [str(k.toString(QKeySequence.SequenceFormat.PortableText)) for k in sc['keys']]
                kmap[un] = keys
        with self.keyboard.config:
            self.keyboard.config['map'] = kmap
            self.keyboard.config['options_map'] = options_map

    def universal_set(self):
        ans = set()
        for i, group in enumerate(self.data):
            ans.add((i, -1))
            for j, sc in enumerate(group.children):
                ans.add((i, j))
        return ans

    def get_matches(self, location, query, candidates=None):
        if candidates is None:
            candidates = self.universal_set()
        ans = set()
        if not query:
            return ans
        query = lower(query)
        for c, p in candidates:
            if p < 0:
                if query in lower(self.data[c].data):
                    ans.add((c, p))
            else:
                try:
                    sc = self.data[c].children[p].data
                except Exception:
                    continue
                if query in lower(sc['name']) or query in lower(sc.get('desc') or ''):
                    ans.add((c, p))
                else:
                    for key in sc['keys']:
                        if query in lower(key.toString(QKeySequence.SequenceFormat.NativeText)):
                            ans.add((c, p))
                            break
        return ans

    def find(self, query):
        query = query.strip()
        if not query:
            return ROOT
        matches = self.parse(query)
        if not matches:
            return ROOT
        matches = list(sorted(matches))
        c, p = matches[0]
        cat_idx = self.index(c, 0)
        if p == -1:
            return cat_idx
        return self.index(p, 0, cat_idx)

    def find_next(self, idx, query, backwards=False):
        query = query.strip()
        if not query:
            return idx
        matches = self.parse(query)
        if not matches:
            return idx
        if idx.parent().isValid():
            loc = (idx.parent().row(), idx.row())
        else:
            loc = (idx.row(), -1)
        if loc not in matches:
            return self.find(query)
        if len(matches) == 1:
            return ROOT
        matches = list(sorted(matches))
        i = matches.index(loc)
        if backwards:
            ans = i - 1 if i - 1 >= 0 else len(matches)-1
        else:
            ans = i + 1 if i + 1 < len(matches) else 0

        ans = matches[ans]

        return (self.index(ans[0], 0) if ans[1] < 0 else
                self.index(ans[1], 0, self.index(ans[0], 0)))

    def index_for_group(self, name):
        for i in range(self.rowCount()):
            node = self.data[i]
            if node.data == name:
                return self.index(i, 0)

    @property
    def group_names(self):
        for i in range(self.rowCount()):
            node = self.data[i]
            yield node.data

# }}}


class Editor(QFrame):  # {{{

    editing_done = pyqtSignal(object)

    def __init__(self, parent=None):
        QFrame.__init__(self, parent)
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
        self.setAutoFillBackground(True)
        self.capture = 0

        self.setFrameShape(QFrame.Shape.StyledPanel)
        self.setFrameShadow(QFrame.Shadow.Raised)
        self._layout = l = QGridLayout(self)
        self.setLayout(l)

        self.header = QLabel('')
        l.addWidget(self.header, 0, 0, 1, 2)

        self.use_default = QRadioButton('')
        self.use_custom = QRadioButton(_('&Custom'))
        l.addWidget(self.use_default, 1, 0, 1, 3)
        l.addWidget(self.use_custom, 2, 0, 1, 3)
        self.use_custom.toggled.connect(self.custom_toggled)

        off = 2
        for which in (1, 2):
            text = _('&Shortcut:') if which == 1 else _('&Alternate shortcut:')
            la = QLabel(text)
            la.setStyleSheet('QLabel { margin-left: 1.5em }')
            l.addWidget(la, off+which, 0, 1, 3)
            setattr(self, f'label{which}', la)
            button = QPushButton(_('None'), self)
            button.setObjectName(_('None'))
            button.clicked.connect(partial(self.capture_clicked, which=which))
            button.installEventFilter(self)
            setattr(self, f'button{which}', button)
            clear = QToolButton(self)
            clear.setIcon(QIcon.ic('clear_left.png'))
            clear.clicked.connect(partial(self.clear_clicked, which=which))
            setattr(self, f'clear{which}', clear)
            l.addWidget(button, off+which, 1, 1, 1)
            l.addWidget(clear, off+which, 2, 1, 1)
            la.setBuddy(button)

        self.done_button = doneb = QPushButton(_('Done'), self)
        l.addWidget(doneb, 0, 2, 1, 1)
        doneb.clicked.connect(lambda: self.editing_done.emit(self))
        l.setColumnStretch(0, 100)

        self.custom_toggled(False)

    def initialize(self, shortcut, all_shortcuts):
        self.header.setText('<b>{}: {}</b>'.format(_('Customize'), shortcut['name']))
        self.all_shortcuts = all_shortcuts
        self.shortcut = shortcut

        self.default_keys = [QKeySequence(k, QKeySequence.SequenceFormat.PortableText) for k
                in shortcut['default_keys']]
        self.current_keys = list(shortcut['keys'])
        default = ', '.join([str(k.toString(QKeySequence.SequenceFormat.NativeText)) for k in
                    self.default_keys])
        if not default:
            default = _('None')
        current = ', '.join([str(k.toString(QKeySequence.SequenceFormat.NativeText)) for k in
                    self.current_keys])
        if not current:
            current = _('None')

        self.use_default.setText(_('&Default: %(deflt)s [Currently not conflicting: %(curr)s]')%
                dict(deflt=default, curr=current))

        if shortcut['set_to_default']:
            self.use_default.setChecked(True)
        else:
            self.use_custom.setChecked(True)
            for key, which in zip(self.current_keys, [1,2]):
                button = getattr(self, f'button{which}')
                ns = key.toString(QKeySequence.SequenceFormat.NativeText)
                button.setText(ns.replace('&', '&&'))
                button.setObjectName(ns)

    def custom_toggled(self, checked):
        for w in ('1', '2'):
            for o in ('label', 'button', 'clear'):
                getattr(self, o+w).setEnabled(checked)

    def capture_clicked(self, which=1):
        self.capture = which
        button = getattr(self, f'button{which}')
        button.setText(_('Press a key...'))
        button.setFocus(Qt.FocusReason.OtherFocusReason)
        button.setStyleSheet('QPushButton { font-weight: bold}')

    def clear_clicked(self, which=0):
        button = getattr(self, f'button{which}')
        button.setText(_('None'))
        button.setObjectName(_('None'))

    def eventFilter(self, obj, event):
        if self.capture and obj in (self.button1, self.button2):
            t = event.type()
            if t == QEvent.Type.ShortcutOverride:
                event.accept()
                return True
            if t == QEvent.Type.KeyPress and isinstance(event, QKeyEvent):
                self.key_press_event(event, 1 if obj is self.button1 else 2)
                return True
        return QFrame.eventFilter(self, obj, event)

    def key_press_event(self, ev, which=0):
        if self.capture == 0:
            return QWidget.keyPressEvent(self, ev)
        sequence = keysequence_from_event(ev)
        if sequence is None:
            return QWidget.keyPressEvent(self, ev)
        ev.accept()

        button = getattr(self, f'button{which}')
        button.setStyleSheet('QPushButton { font-weight: normal}')
        ns = sequence.toString(QKeySequence.SequenceFormat.NativeText)
        button.setText(ns.replace('&', '&&'))
        button.setObjectName(ns)
        self.capture = 0
        dup_desc = self.dup_check(sequence)
        if dup_desc is not None:
            error_dialog(self, _('Already assigned'),
                    str(sequence.toString(QKeySequence.SequenceFormat.NativeText)) + ' ' + _(
                        'already assigned to') + ' ' + dup_desc, show=True)
            self.clear_clicked(which=which)

    def dup_check(self, sequence):
        for sc in self.all_shortcuts:
            if sc is self.shortcut:
                continue
            for k in sc['keys']:
                if k == sequence:
                    return sc['name']

    @property
    def custom_keys(self):
        if self.use_default.isChecked():
            return None
        ans = []
        for which in (1, 2):
            button = getattr(self, f'button{which}')
            t = button.objectName()
            if not t or t == _('None'):
                continue
            ks = QKeySequence(t, QKeySequence.SequenceFormat.NativeText)
            if not ks.isEmpty():
                ans.append(ks)
        return tuple(ans)

# }}}


class Delegate(QStyledItemDelegate):  # {{{

    changed_signal = pyqtSignal()

    def __init__(self, parent=None):
        QStyledItemDelegate.__init__(self, parent)
        self.editing_index = None
        self.closeEditor.connect(self.editing_done)

    def to_doc(self, index):
        data = index.data(Qt.ItemDataRole.UserRole)
        if data is None:
            html = _('<b>This shortcut no longer exists</b>')
        elif data.is_shortcut:
            shortcut = data.data
            # Shortcut
            keys = [str(k.toString(QKeySequence.SequenceFormat.NativeText)) for k in shortcut['keys']]
            if not keys:
                keys = _('None')
            else:
                keys = ', '.join(keys)
            html = '<b>{}</b><br>{}: {}'.format(
                prepare_string_for_xml(shortcut['name']), _('Shortcuts'), prepare_string_for_xml(keys))
        else:
            # Group
            html = data.data
        doc = QTextDocument()
        doc.setHtml(html)
        return doc

    def sizeHint(self, option, index):
        if index == self.editing_index:
            return QSize(200, 200)
        ans = self.to_doc(index).size().toSize()
        return ans

    def paint(self, painter, option, index):
        painter.save()
        painter.setClipRect(QRectF(option.rect))
        if hasattr(QStyle, 'CE_ItemViewItem'):
            QApplication.style().drawControl(QStyle.ControlElement.CE_ItemViewItem, option, painter)
        elif option.state & QStyle.StateFlag.State_Selected:
            painter.fillRect(option.rect, option.palette.highlight())
        painter.translate(option.rect.topLeft())
        self.to_doc(index).drawContents(painter)
        painter.restore()

    def createEditor(self, parent, option, index):
        w = Editor(parent=parent)
        w.editing_done.connect(self.editor_done)
        self.editing_index = index
        self.current_editor = w
        self.sizeHintChanged.emit(index)
        return w

    def accept_changes(self):
        self.editor_done(self.current_editor)

    def editor_done(self, editor):
        self.commitData.emit(editor)

    def setEditorData(self, editor, index):
        all_shortcuts = [x.data for x in index.model().all_shortcuts]
        shortcut = index.internalPointer().data
        editor.initialize(shortcut, all_shortcuts)

    def setModelData(self, editor, model, index):
        self.closeEditor.emit(editor, QAbstractItemDelegate.EndEditHint.NoHint)
        custom_keys = editor.custom_keys
        sc = index.data(Qt.ItemDataRole.UserRole).data
        if custom_keys is None:
            candidates = []
            for ckey in sc['default_keys']:
                ckey = QKeySequence(ckey, QKeySequence.SequenceFormat.PortableText)
                matched = False
                for s in editor.all_shortcuts:
                    if s is editor.shortcut:
                        continue
                    for k in s['keys']:
                        if k == ckey:
                            matched = True
                            break
                if not matched:
                    candidates.append(ckey)
            candidates = tuple(candidates)
            sc['set_to_default'] = True
        else:
            sc['set_to_default'] = False
            candidates = custom_keys
        sc['keys'] = candidates
        self.changed_signal.emit()

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)

    def editing_done(self, *args):
        self.current_editor = None
        idx = self.editing_index
        self.editing_index = None
        if idx is not None:
            self.sizeHintChanged.emit(idx)

# }}}


class ShortcutConfig(QWidget):  # {{{

    changed_signal = pyqtSignal()

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self._layout = l = QVBoxLayout(self)
        self.header = QLabel(_('Double click on any entry to change the'
            ' keyboard shortcuts associated with it'))
        l.addWidget(self.header)
        self.view = QTreeView(self)
        self.view.setAlternatingRowColors(True)
        self.view.setHeaderHidden(True)
        self.view.setAnimated(True)
        self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.view.customContextMenuRequested.connect(self.show_context_menu)
        l.addWidget(self.view)
        self.delegate = Delegate()
        self.view.setItemDelegate(self.delegate)
        self.delegate.sizeHintChanged.connect(self.editor_opened,
                type=Qt.ConnectionType.QueuedConnection)
        self.delegate.changed_signal.connect(self.changed_signal)
        self.search = SearchBox2(self)
        self.search.initialize('shortcuts_search_history',
                help_text=_('Search for a shortcut by name or key combination'))
        self.search.search.connect(self.find)
        self._h = h = QHBoxLayout()
        l.addLayout(h)
        h.addWidget(self.search)
        self.nb = QPushButton(QIcon.ic('arrow-down.png'), _('&Next'), self)
        self.pb = QPushButton(QIcon.ic('arrow-up.png'), _('&Previous'), self)
        self.nb.clicked.connect(self.find_next)
        self.pb.clicked.connect(self.find_previous)
        h.addWidget(self.nb), h.addWidget(self.pb)
        h.setStretch(0, 100)

    def show_context_menu(self, pos):
        menu = QMenu(self)
        menu.addAction(QIcon.ic('plus.png'), _('Expand all'), self.view.expandAll)
        menu.addAction(QIcon.ic('minus.png'), _('Collapse all'), self.view.collapseAll)
        menu.exec(self.view.mapToGlobal(pos))

    def restore_defaults(self):
        self._model.restore_defaults()
        self.changed_signal.emit()

    def commit(self):
        if self.view.state() == QAbstractItemView.State.EditingState:
            self.delegate.accept_changes()
        self._model.commit()

    def initialize(self, keyboard):
        self._model = ConfigModel(keyboard, parent=self)
        self.view.setModel(self._model)

    def editor_opened(self, index):
        self.view.scrollTo(index, QAbstractItemView.ScrollHint.EnsureVisible)

    @property
    def is_editing(self):
        return self.view.state() == QAbstractItemView.State.EditingState

    def find(self, query):
        if not query:
            return
        try:
            idx = self._model.find(query)
        except ParseException:
            self.search.search_done(False)
            return
        self.search.search_done(True)
        if not idx.isValid():
            info_dialog(self, _('No matches'),
                    _('Could not find any shortcuts matching <i>{}</i>').format(prepare_string_for_xml(query)),
                    show=True, show_copy_button=False)
            return
        self.highlight_index(idx)

    def highlight_index(self, idx):
        self.view.scrollTo(idx)
        self.view.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect)
        self.view.setCurrentIndex(idx)
        self.view.setFocus(Qt.FocusReason.OtherFocusReason)

    def find_next(self, *args):
        idx = self.view.currentIndex()
        if not idx.isValid():
            idx = self._model.index(0, 0)
        idx = self._model.find_next(idx,
                str(self.search.currentText()))
        self.highlight_index(idx)

    def find_previous(self, *args):
        idx = self.view.currentIndex()
        if not idx.isValid():
            idx = self._model.index(0, 0)
        idx = self._model.find_next(idx,
            str(self.search.currentText()), backwards=True)
        self.highlight_index(idx)

    def highlight_group(self, group_name):
        idx = self.view.model().index_for_group(group_name)
        if idx is not None:
            self.view.expand(idx)
            self.view.scrollTo(idx, QAbstractItemView.ScrollHint.PositionAtTop)
            self.view.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect)
            self.view.setCurrentIndex(idx)
            self.view.setFocus(Qt.FocusReason.OtherFocusReason)

# }}}
